Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): Add Support for Payments Dynamic Tax Calculation Based on Shipping Address #5619

Merged
merged 50 commits into from
Sep 6, 2024

Conversation

swangi-kumari
Copy link
Contributor

@swangi-kumari swangi-kumari commented Aug 13, 2024

Type of Change

  • Bugfix
  • New feature
  • Enhancement
  • Refactoring
  • Dependency updates
  • Documentation
  • CI/CD

Description

  • Add Support for Payments Dynamic Tax Calculation Based on Shipping Address
  • If merchants are using Hyperswitch’s Express checkout feature to collect shipping details directly from wallets like Apple pay, Paypal, Klarna, etc. they would need a way to dynamically update sales tax and shipping cost based on the shipping address their customers select.
  • The sales tax typically varies between countries in the EU whereas it varies between states in the US.
  • We have added a new flow, SdkSessionUpdate, where, when a user changes the shipping address, the connector will send us an event. We will then call the TaxJar API to calculate the tax based on the shipping address and shipping cost. TaxJar will return the calculated tax. After this, we will send the total amount, which includes the calculated tax and shipping cost, back to the connector.

Additional Changes

  • This PR modifies the API contract
  • This PR modifies the database schema
  • This PR modifies application configuration/environment variables

Motivation and Context

How did you test it?

  1. Create a Merchant Account
curl --location 'http://localhost:8080/accounts' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--header 'api-key: test_admin' \
--data-raw '{
  "merchant_id": "merchant_1725535996",
  "locker_id": "m0010",
  "merchant_name": "NewAge Retailer",
  "merchant_details": {
    "primary_contact_person": "John Test",
    "primary_email": "JohnTest@test.com",
    "primary_phone": "sunt laborum",
    "secondary_contact_person": "John Test2",
    "secondary_email": "JohnTest2@test.com",
    "secondary_phone": "cillum do dolor id",
    "website": "https://www.example.com",
    "about_business": "Online Retail with a wide selection of organic products for North America",
    "address": {
      "line1": "1467",
      "line2": "Harrison Street",
      "line3": "Harrison Street",
      "city": "San Fransico",
      "state": "California",
      "zip": "94122",
      "country": "US"
    }
  },
  "return_url": "https://google.com/success",
  "webhook_details": {
    "webhook_version": "1.0.1",
    "webhook_username": "ekart_retail",
    "webhook_password": "password_ekart@123",
    "payment_created_enabled": true,
    "payment_succeeded_enabled": true,
    "payment_failed_enabled": true
  },
  "sub_merchants_enabled": false,
  "metadata": {
    "city": "NY",
    "unit": "245"
  },
  "primary_business_details": [
    {
      "country": "US",
      "business": "food"
    }
  ]
}'
  1. create Api key
curl --location 'http://localhost:8080/api_keys/merchant_1725532194' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--header 'api-key: test_admin' \
--data '{
  "name": "API Key 1",
  "description": null,
  "expiration": "2038-01-19T03:14:08.000Z"
}'

Create a Payment Connector Create of Stripe for ApplePay

{
    "connector_type": "payment_processor",
    "connector_name": "stripe",
    "connector_account_details": {
        "auth_type": "HeaderKey",
        "api_key": "__________________"
    },
    "test_mode": true,
    "disabled": false,
    "payment_methods_enabled": [
        {
            "payment_method": "card",
            "payment_method_types": [
                {
                    "payment_method_type": "credit",
                    "payment_experience": null,
                    "card_networks": [
                        "Visa",
                        "Mastercard"
                    ],
                    "accepted_currencies": null,
                    "accepted_countries": null,
                    "minimum_amount": -1,
                    "maximum_amount": 68607706,
                    "recurring_enabled": true,
                    "installment_payment_enabled": true
                },
                {
                    "payment_method_type": "debit",
                    "payment_experience": null,
                    "card_networks": [
                        "Visa",
                        "Mastercard"
                    ],
                    "accepted_currencies": null,
                    "accepted_countries": null,
                    "minimum_amount": -1,
                    "maximum_amount": 68607706,
                    "recurring_enabled": true,
                    "installment_payment_enabled": true
                }
            ]
        },
        {
            "payment_method": "wallet",
            "payment_method_types": [
                {
                    "payment_method_type": "apple_pay",
                    "payment_experience": "invoke_sdk_client",
                    "minimum_amount": 1,
                    "maximum_amount": 68607706,
                    "recurring_enabled": true,
                    "installment_payment_enabled": true
                },
            ]
        },
        {
            "payment_method": "pay_later",
            "payment_method_types": [
                {
                    "payment_method_type": "klarna",
                    "payment_experience": "redirect_to_url",
                    "card_networks": null,
                    "accepted_currencies": null,
                    "accepted_countries": null,
                    "minimum_amount": 1,
                    "maximum_amount": 68607706,
                    "recurring_enabled": true,
                    "installment_payment_enabled": true
                }
            ]
        }
    ],
    "metadata": {
        "apple_pay_combined": {
            "manual": {
                "session_token_data": {
                    "initiative": "web",
                    "certificate": "___________",
                    "display_name": "applepay",
                    "certificate_keys": "_______",
                    "payment_processing_details_at": "Connector",
                    "initiative_context": "sdk-test-app.netlify.app",
                    "merchant_identifier": "merchant.com.stripe.sang",
                    "merchant_business_country": "US"
                },
                "payment_request_data": {
                    "label": "applepay",
                    "supported_networks": [
                        "visa",
                        "masterCard",
                        "amex",
                        "discover"
                    ],
                    "merchant_capabilities": [
                        "supports3DS"
                    ]
                }
            }
        },
        "google_pay": {
            "merchant_info": {
                "merchant_name": "Stripe"
            },
            "allowed_payment_methods": [
                {
                    "type": "CARD",
                    "parameters": {
                        "allowed_auth_methods": [
                            "PAN_ONLY",
                            "CRYPTOGRAM_3DS"
                        ],
                        "allowed_card_networks": [
                            "AMEX",
                            "DISCOVER",
                            "INTERAC",
                            "JCB",
                            "MASTERCARD",
                            "VISA"
                        ]
                    },
                    "tokenization_specification": {
                        "type": "PAYMENT_GATEWAY",
                        "parameters": {
                            "gateway": "stripe",
                            "stripe:version": "2018-10-31",
                            "stripe:publishableKey": "___________"
                        }
                    }
                }
            ]
        }
    }
}

4.Payment Connector Create for TaxJar

{
    "connector_type": "tax_processor",
    "connector_name": "taxjar",
    "connector_account_details": {
        "auth_type": "HeaderKey",
        "api_key": "_________" 
    },

    "test_mode": false,
    "disabled": false,
     "payment_methods_enabled": [
        {
            "payment_method": "upi",
            "payment_method_types": [
                {
                    "payment_method_type": "upi_collect",
                    "payment_experience": "redirect_to_url",
                    "accepted_currencies": {
                        "type" : "enable_only",
                        "list": ["INR"]
                    },
                    "accepted_countries": {
                        "type" : "enable_only",
                        "list": ["IN"]
                    },
                    "minimum_amount": 1,
                    "maximum_amount": 68607706,
                    "recurring_enabled": true,
                    "installment_payment_enabled": true
                }
            ]
        }

    ],
    "metadata": {
        "city": "NY",
        "unit": "245"
    },
    "connector_webhook_details": {
        "merchant_secret": "dce1d767-7c28-429c-b0fe-9e0b9e91f962"
    },
    "business_country": "US",
    "business_label": "food"
}
  1. Payment Create
{
    "amount": 2000,
    "currency": "USD",
    "confirm": false,
    "capture_method": "automatic",
    "capture_on": "2022-09-10T10:11:12Z",
    "customer_id": "StripeCustomer",
    "email": "guest@example.com",
    "name": "John Doe",
    "phone": "999999999",
    "phone_country_code": "+1",
    "description": "Its my first payment request",
    "authentication_type": "no_three_ds",
    "return_url": "https://google.com",
    "shipping_cost": 500,
    "billing": {
        "address": {
            "line1": "1467",
            "line2": "Harrison Street",
            "line3": "Harrison Street",
            "city": "San Fransico",
            "state": "California",
            "zip": "94122",
            "country": "DE",
            "first_name": "joseph",
            "last_name": "Doe"
        },
        "phone": {
            "number": "8056594427",
            "country_code": "+91"
        },
        "email": "swangi@gmail.com"
    },
    "order_details": [
        {
            "product_name": "Apple iphone 15",
            "quantity": 1,
            "amount": 2000,
            "account_name": "transaction_processing",
            "product_id": "1",
            "product_tax_code": "23"
        }
    ],
    "statement_descriptor_name": "joseph",
    "statement_descriptor_suffix": "JS",
    "metadata": {
        "udf1": "value1",
        "new_customer": "true",
        "login_date": "2019-09-10T10:11:12Z"
    }
}
  1. Update business_profile
curl --location 'http://localhost:8080/account/merchant_1725543554/business_profile/{pro____}' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--header 'api-key: test_admin' \
--data '{
    "tax_connector_id": "{mca_____}",
    "is_tax_connector_enabled": true
}'
  1. Create session Token
curl --location 'http://localhost:8080/payments/session_tokens' \
--header 'Content-Type: application/json' \
--header 'api-key: pk_dev_85f56ada7b2f4ffab21fe0dc214a0d75' \
--data '{
    "payment_id": "pay_PNd6JEhLnwe6q7K5LKJ0",
    "wallets": [],
    "client_secret": "pay_PNd6JEhLnwe6q7K5LKJ0_secret_BPT9z1RBmCFQJVvAGJJ2"
}'
  1. List Payment Methods
curl --location 'http://localhost:8080/account/payment_methods?client_secret={}' \
--header 'Accept: application/json' \
--header 'api-key: {pk_dev_}'
curl --location 'http://localhost:8080/payments/pay_PNd6JEhLnwe6q7K5LKJ0/calculate_tax' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--header 'api-key: {pk_dev}' \
--data-raw '{
    "client_secret": "{}",
    "shipping": {
        "address": {
            "line1": "1335 E 103rd St",
            "line2": " Street",
            "line3": " Street",
            "city": "Los Angeles",
            "state": "California",
            "zip": "90002",
            "country": "US",
            "first_name": "joseph",
            "last_name": "Doe"
        },
        "phone": {
            "number": "8056594427",
            "country_code": "+91"
        },
        "email": "swangi@gmail.com"
    },
    "payment_method_type" : "klarna"
}'

Response

{
    "net_amount": 2690
}

Checklist

  • I formatted the code cargo +nightly fmt --all
  • I addressed lints thrown by cargo clippy
  • I reviewed the submitted code
  • I added unit tests for my changes where possible

@hyperswitch-bot hyperswitch-bot bot added the M-database-changes Metadata: This PR involves database schema changes label Aug 13, 2024
@hyperswitch-bot hyperswitch-bot bot added the M-api-contract-changes Metadata: This PR involves API contract changes label Aug 13, 2024
@swangi-kumari swangi-kumari added A-connector-integration Area: Connector integration A-core Area: Core flows labels Aug 22, 2024
attempt_count: None,
merchant_decision: None,
payment_confirm_source: None,
updated_by: String::default(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do not update this as default, take it in params and update

customer_details: None,
billing_details: None,
merchant_order_reference_id: None,
shipping_details: None,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update this field too with the shipping address

@@ -257,6 +258,10 @@ pub enum PaymentIntentUpdate {
status: Option<storage_enums::IntentStatus>,
updated_by: String,
},
SessionResponseUpdate {
tax_details: diesel_models::TaxDetails,
shipping_address_id: Option<String>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

take update_by here

#[derive(Debug, Clone)]
pub struct TaxCalculationResponseData {
pub order_tax_amount: MinorUnit,
pub net_amount: MinorUnit,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we are not using this anywhere, please remove this field

_business_profile: &domain::BusinessProfile,
_header_payload: api_models::payments::HeaderPayload,
) -> RouterResult<Self> {
if connector.connector_name == types::Connector::Klarna {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do not need this check here, this will be handled in routing

@@ -488,6 +488,104 @@ impl<F: Clone> PostUpdateTracker<F, PaymentData<F>, types::PaymentsSessionData>
}
}

#[async_trait]
impl<F: Clone> PostUpdateTracker<F, PaymentData<F>, types::PaymentsTaxCalculationData>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can remove this if this is not used anywhere

.ok_or(errors::ApiErrorResponse::MissingRequiredField {
field_name: "order_tax_amount",
})?;
let amount = MinorUnit::from(payment_data.amount);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

take this from payment_attempt

amount = amount + order_tax_amount;
}
Ok(services::ApplicationResponse::JsonWithHeaders((
Self { net_amount: amount },
Copy link
Member

@Narayanbhat166 Narayanbhat166 Sep 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we only need net amount in the response? What about the breakup of amount?
cc: @SamraatBansal

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should ideally send the break up, the response api contract needs to be revisited along with SDK team

@swangi-kumari swangi-kumari changed the title feat(core): Tax jar api feat(core): Add Support for Payments Dynamic Tax Calculation Based on Shipping Address Sep 5, 2024
@@ -1317,3 +1408,124 @@ macro_rules! unimplemented_payment_method {
))
};
}

impl ForeignTryFrom<String> for UsStatesAbbreviation {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would prefer to avoid using ForeignTryFrom outside the router crate.
Can we instead implement this method directly on the UsStatesAbbreviation enum?

This can be taken up in a separate PR.

@@ -139,7 +140,8 @@ impl<
+ ConnectorMandateRevoke
+ ConnectorMandateRevokeV2
+ ExternalAuthentication
+ ExternalAuthenticationV2,
+ ExternalAuthenticationV2
+ PaymentTaxCalculation,
Copy link
Member

@Narayanbhat166 Narayanbhat166 Sep 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need payment here in the trait name

Copy link
Contributor

@SamraatBansal SamraatBansal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need extensive testing, in flows like Payments - Create and Confirm

amount = amount + order_tax_amount;
}
Ok(services::ApplicationResponse::JsonWithHeaders((
Self { net_amount: amount },
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should ideally send the break up, the response api contract needs to be revisited along with SDK team

#[diesel(sql_type = diesel::sql_types::Jsonb)]
pub struct TaxDetails {
pub default: Option<DefaultTax>,
pub payment_method_type: Option<PaymentMethodTypeTax>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this tamper proof if somebody changes the address in the confirm call?

@@ -1453,6 +1453,7 @@ impl<'a> ConnectorAuthTypeAndMetadataValidation<'a> {
stax::transformers::StaxAuthType::try_from(self.auth_type)?;
Ok(())
}
api_enums::Connector::Taxjar => Ok(()),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auth type validation is missing

_merchant_recipient_data: Option<types::MerchantRecipientData>,
) -> RouterResult<types::SdkSessionUpdateRouterData> {
Box::pin(
transformers::construct_router_data_to_update_calculated_tax::<
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file should be renamed as session update flow

let db = state.store.as_ref();

let key_manager_state: &KeyManagerState = &state.into();

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: add a check if payments_create had a skip_tax_calculation as true or not

) -> CustomResult<(), errors::ApiErrorResponse> {
let db = state.store.as_ref();
let key_manager_state: &KeyManagerState = &state.into();

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: Check for skip_tax flag from intent DB, dont check it from update request

key_store: &domain::MerchantKeyStore,
merchant_account: &domain::MerchantAccount,
) -> errors::CustomResult<(), errors::ApiErrorResponse> {
if business_profile.is_tax_connector_enabled {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check for condition as mentioned above

_connectors: &Connectors,
) -> CustomResult<RequestContent, errors::ConnectorError> {
let amount = utils::convert_amount(
self.amount_converter,
req.request.minor_amount,
req.request.amount,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
req.request.amount,
req.request.minor_amount,

Comment on lines +80 to +82
.ok_or(errors::ConnectorError::MissingRequiredField {
field_name: "address",
})?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How are missing_required_params propagated in the api response?

We should give 2xx or SDK should handle a 4xx Scenario? @jarnura

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my opinion, The api is supposed to calculate the tax with the given inputs. If it is not able to calculate it for some reason, it should not send a 2xx response. Missing required fields can occur because of 2 cases

  • Integration errors
    We expected some field to be sent from the merchant in the payments create request or while creating the business profile, these should be caught in the integration phase.

  • Insufficient data from the customer / wallet
    Some fields that are required by the underlying connector to calculate the tax are not received by the api. You check this case by testing the flow with different wallet providers. We can throw 4xx if it can be handled by the sdk to collect these fields.

@Narayanbhat166
Copy link
Member

We should ideally send the break up, the response api contract needs to be revisited along with SDK team

Yes @SamraatBansal @swangi-kumari we should also send the tax breakup in payments create call. This will be useful for merchant if he wants to display the tax breakup on the checkout page. The payments response should have this response populated

Copy link
Contributor

@apoorvdixit88 apoorvdixit88 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dashboard specific changes look fine

Comment on lines +68 to +70
Some(ApiEventsType::Payment {
payment_id: self.payment_id.clone(),
})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this part of the payment lifecycle ?,
if not this can be none

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes this is part of payment life cycle.

@likhinbopanna likhinbopanna added this pull request to the merge queue Sep 6, 2024
Merged via the queue into main with commit a03ad53 Sep 6, 2024
32 of 34 checks passed
@likhinbopanna likhinbopanna deleted the tax-jar-api branch September 6, 2024 14:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-connector-integration Area: Connector integration A-core Area: Core flows M-api-contract-changes Metadata: This PR involves API contract changes M-database-changes Metadata: This PR involves database schema changes
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants